using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Configuration;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Net.Mail;
using System.Text;
using System.Xml;
using System.Xml.Xsl;

using Microsoft.TeamFoundation.VersionControl.Client;

using TfsSpam.Differ.Logging;
using TfsSpam.Utilities;

namespace TfsSpam.Differ
{
    /// <summary>
    /// The CheckinEventProcessor class handles all the tasks of processing
    /// a checkin event and sending out notification e-mails to the configured
    /// recipients.
    /// </summary>
    public class CheckinEventProcessor
    {
        #region -- Private class members
        /// <summary>
        /// The logger used to keep track of what we're doing.
        /// </summary>
        SpamLogger _logger;
        #endregion

        #region -- Constructor
        /// <summary>
        /// Standard constructor for the class, defines the id of
        /// the changeset that should be processed.
        /// </summary>
        /// <param name="changesetId">The id of the changeset to process.</param>
        /// <exception cref="CheckinEventProcessorException">Thrown if we can't connect to the configured TFS server.</exception>
        public CheckinEventProcessor( int changesetId, System.Configuration.Configuration configuration )
        {
            _logger = new SpamLogger( changesetId );

            if ( configuration == null )
            {
                throw new ArgumentNullException( "configuration", Properties.Strings.CheckinEventProcessor_ConfigurationMustBeProvided );
            }
            else
            {
                _configuration = configuration.GetSectionGroup( "tfsSpam" ) as Configuration.TfsSpamConfig;
            }

            try
            {
                _logger.Information( "Creating a CheckinEventProcessor for the changeset" );
                _changeset = VersionControl.GetChangeset( changesetId );
            }
            catch ( RepositoryNotFoundException rnfex )
            {
                string exceptionMessage = string.Format( CultureInfo.InvariantCulture, Properties.Strings.CheckinEventProcessor_CouldNotConnectToTfs, Settings.TeamProjects.ServerUrl );

                _logger.Critical( "Couldn't connect to the configured TFS server ({0})", Settings.TeamProjects.ServerUrl );
                throw new CheckinEventProcessorException( exceptionMessage, rnfex );
            }
        }
        #endregion

        #region -- Public methods
        /// <summary>
        /// Checks to see if the changeset the object represents needs to
        /// be processed and if so generates the changeset information and
        /// e-mails it to the defined recipients.
        /// </summary>
        public void ProcessChangeset()
        {
            _logger.Verbose( "CheckinEventProcessor.ProcessChangeset" );

            if ( ShouldProcessChangeset() )
            {
                _logger.Information( "Preparing to process the changeset" );
                ProcessCheckinNotification();
            }
        }
        #endregion

        #region -- Protected properties
        /// <summary>
        /// The <see cref="Microsoft.TeamFoundation.VersionControl.Client.Changeset"/> being
        /// processed.
        /// </summary>
        protected Changeset Changeset
        {
            get { return _changeset; }
        } private readonly Changeset _changeset;

        /// <summary>
        /// The list of <see cref="TeamProject"/>s affected by the
        /// <see cref="CheckinEventProcessor.Changeset"/>.
        /// </summary>
        protected ReadOnlyCollection<TeamProject> AffectedProjects
        {
            get
            {
                if ( _affectedProjects == null )
                {
                    _affectedProjects = GetChangesetProjects();
                }

                return _affectedProjects;
            }
        } private ReadOnlyCollection<TeamProject> _affectedProjects;
        #endregion

        #region -- Private properties
        /// <summary>
        /// The <see cref="ChangesetDiff"/> object used to analyze the
        /// changeset being processed.
        /// </summary>
        private ChangesetDiff ChangesetDiffer
        {
            get
            {
                if ( _changesetDiffer == null )
                {
                    _logger.Information( "Creating the changeset differ object" );
                    ChangesetDiff differ = new ChangesetDiff( VersionControl, Changeset, _logger );

                    // Add the file types that have been configured for detail processing.
                    foreach ( Configuration.FileType fileType in Settings.FileTypes.Types )
                    {
                        _logger.Verbose( "Adding the file type {0}", fileType.Extension );
                        differ.AddFileDetailExtension( fileType.Extension );
                    }

                    // If code highlighting is enabled, add the prefixes that should be
                    // recognized for highlighting.
                    if ( Settings.Highlights != null && Settings.Highlights.Enabled )
                    {
                        _logger.Verbose( "Adding {0} code highlight prefixes", Settings.Highlights.Prefixes.Count );
                        foreach ( Configuration.Highlight highlight in Settings.Highlights.Prefixes )
                        {
                            differ.AddHighlightPrefix( highlight.Prefix );
                        }
                    }

                    // Define the number of lines to include when comparing files.
                    differ.MaxFileLines = (uint) Settings.Diff.MaxLines;
                    differ.NumberOfContextLines = (uint) Settings.Diff.ContextLines;
                    _logger.Verbose( "MaxFileLines = {0}, NumberOfContextLines = {1}", differ.MaxFileLines, differ.NumberOfContextLines );

                    _changesetDiffer = differ;
                }

                return _changesetDiffer;
            }
        } private ChangesetDiff _changesetDiffer;
        #endregion

        #region -- Changeset processing
        /// <summary>
        /// Processes a checkin notification by retrieving the information about
        /// the associated changeset and e-mailing that to the configured recipients.
        /// </summary>
        private void ProcessCheckinNotification()
        {
            string analysisXmlPath = Path.GetTempFileName();
            string emailBodyPath = Path.GetTempFileName();

            try
            {
                _logger.Verbose( "Analysis file path: '{0}'", analysisXmlPath );
                _logger.Verbose( "Email body path: '{0}'", emailBodyPath );

                // Generate the changeset analysis XML document...
                _logger.Information( "Creating the changeset analysis document" );
                using ( FileStream analysisFile = new FileStream( analysisXmlPath, FileMode.Create, FileAccess.Write ) )
                {
                    ChangesetDiffer.WriteChangesetDocument( analysisFile );
                }

                // ...transform the changeset XML analysis document to a HTML document that
                // we'll use as the e-mail body...
                _logger.Information( "Executing the XSLT transform" );
                TransformChangesetDocument( analysisXmlPath, emailBodyPath );

                // ...and finally send the e-mail to the configured recipients.
                _logger.Information( "Sending the information to the configured recipients" );
                SendEmail( emailBodyPath );
            }
            finally
            {
                _logger.Verbose( "Deleting the analysis and email files" );
                File.Delete( analysisXmlPath );
                File.Delete( emailBodyPath );
            }
        }
        #endregion

        #region -- E-mail processing
        /// <summary>
        /// Sends the check-in notification e-mail to the configured
        /// recipients.
        /// </summary>
        /// <param name="emailBodyPath">
        /// The fully qualified path of the file containing the body of
        /// the e-mail to send.
        /// </param>
        private void SendEmail( string emailBodyPath )
        {
            _logger.Verbose( "Creating the SmptClient object" );
            SmtpClient smtpClient = new SmtpClient();

            using ( MailMessage message = new MailMessage() )
            {
                _logger.Verbose( "Adding the e-mail recipients" );
                if ( AddRecipients( message ) )
                {
                    _logger.Verbose( "Setting the body of of the e-mail" );
                    AddEmailBody( message, emailBodyPath );

                    _logger.Verbose( "Setting the title of the e-mail: '{0}'", ChangesetDiffer.Title );
                    message.Subject = ChangesetDiffer.Title;

                    _logger.Verbose( "Sending the e-mail" );
                    smtpClient.Send( message );
                    _logger.Verbose( "E-mail sent" );
                }
                else
                {
                    _logger.Warning( "No recipients could be found for the message" );
                }
            }
        }

        /// <summary>
        /// Adds the body of the e-mail to the message being sent.
        /// </summary>
        /// <param name="message">The message being prepared.</param>
        /// <param name="emailBodyPath">
        /// The fully qualified path of the file containing the body of
        /// the e-mail to send.
        /// </param>
        private static void AddEmailBody( MailMessage message, string emailBodyPath )
        {
            message.Body = File.ReadAllText( emailBodyPath );
            message.IsBodyHtml = true;
        }

        /// <summary>
        /// Adds the configured recipients to the message being prepared.
        /// </summary>
        /// <param name="message">The message being prepared.</param>
        private bool AddRecipients( MailMessage message )
        {
            // Get the configured recipients for each project affected by this changeset.
            foreach ( TeamProject affectedProject in AffectedProjects )
            {
                Item subscribers = GetSubscribersItem( affectedProject.Name );

                if ( subscribers != null )
                {
                    string projectRecipients = GetSubscribers( subscribers );

                    try
                    {
                        _logger.Information( "Adding the recipients for TFS project '{0}': {1}", affectedProject.Name, projectRecipients );
                        message.To.Add( projectRecipients );
                    }
                    catch ( FormatException fex )
                    {
                        _logger.Error( "Couldn't add the recipients ({2}) for TFS project '{0}': {1}", affectedProject.Name, fex.Message, projectRecipients );
                    }
                }
            }

            // We were successful if at least one recipient was added to the message.
            return ( message.To.Count > 0 );
        }

        /// <summary>
        /// Retrieves the version control item that corresponds to the
        /// file containing the recipients for the check-in notification
        /// e-mails for the specified project.
        /// </summary>
        /// <param name="projectName">The name of the TFS project whose recipients item should be returned.</param>
        /// <returns>The <see cref="Item"/> containing the list of check-in notifications for the specified project.</returns>
        private Item GetSubscribersItem( string projectName )
        {
            foreach ( Configuration.TeamProject project in Settings.TeamProjects.Projects )
            {
                if ( StringComparer.InvariantCultureIgnoreCase.Compare( projectName, project.Name ) == 0 )
                {
                    _logger.Verbose( "Retrieving the item containing the recipients for TFS project '{0}' - '{1}'", projectName, project.SubscribersItemPath );

                    try
                    {
                        return VersionControl.VersionControl.GetItem( project.SubscribersItemPath );
                    }
                    catch ( VersionControlException vcex )
                    {
                        _logger.Error( "Couldn't download the item containing the check-in event subscribers for the project '{0}'. The defined item path is '{1}': ", projectName, project.SubscribersItemPath, vcex.Message );
                    }
                }
            }

            _logger.Warning( "Couldn't find a subscribers item for TFS project '{0}'", projectName );
            return null;
        }

        /// <summary>
        /// Retrieves a comma-separated string containing the e-mail addresses
        /// for the check-in notification recipients defined in the provided
        /// version control item.
        /// </summary>
        /// <param name="subscribersItem">A version control item representing the file containing the configured recipients.</param>
        /// <returns>A comma-separated string containing the configured recipients.</returns>
        private string GetSubscribers( Item subscribersItem )
        {
            if ( subscribersItem != null )
            {
                string subscribersPath = Path.GetTempFileName();

                _logger.Verbose( "Downloading subscribers item '{0}' to '{1}'", subscribersItem.ServerItem, subscribersPath );

                try
                {
                    StringBuilder addresses = new StringBuilder();
                    string[] addressLines;

                    // Get all the configured recipients from the defined item.
                    VersionControl.DownloadFile( subscribersItem, subscribersPath );
                    addressLines = File.ReadAllLines( subscribersPath );

                    _logger.Verbose( "Read {0} recipients from the file", addressLines.Length );

                    // Add each recipient defined on a separate line in the downloaded
                    // file, skipping commented out and empty lines.
                    foreach ( string addressLine in addressLines )
                    {
                        _logger.Verbose( "Processing a line read from the recipients file: '{0}'", addressLine );

                        if ( IsValidAddressLine( addressLine ) )
                        {
                            _logger.Verbose( "Adding the recipient address '{0}'", addressLine );

                            if ( addresses.Length == 0 )
                            {
                                addresses.Append( addressLine );
                            }
                            else
                            {
                                addresses.AppendFormat( ",{0}", addressLine );
                            }
                        }
                    }

                    return addresses.ToString();
                }
                finally
                {
                    _logger.Verbose( "Deleting the downloaded recipients file" );
                    File.Delete( subscribersPath );
                }
            }
            else
            {
                _logger.Warning( "No subscribers item defined" );
                return string.Empty;
            }
        }

        /// <summary>
        /// Determines if a line read from a recipients file appears to be
        /// a line that should be used as an e-mail address.
        /// </summary>
        /// <remarks>
        /// No attempt is made to validate e-mail addresses, but empty lines
        /// as well as lines starting with '//' or '#' are not considered valid.
        /// </remarks>
        /// <param name="addressLine">An address line read from a recipients file.</param>
        /// <returns><c>true</c> if the line appears to be a valid e-mail address line, <c>false</c> otherwise.</returns>
        private static bool IsValidAddressLine( string addressLine )
        {
            if ( string.IsNullOrEmpty( addressLine ) )
            {
                return false;
            }
            else if ( addressLine.IndexOf( "//", StringComparison.Ordinal ) == 0 )
            {
                return false;
            }
            else if ( addressLine.IndexOf( '#' ) == 0 )
            {
                return false;
            }

            return true;
        }
        #endregion

        #region -- XML to HTML transformation
        /// <summary>
        /// Transforms the generated changeset XML analysis document using the configured
        /// XSLT transform to a HTML document that can be used as the body of the checking
        /// notification e-mail.
        /// </summary>
        /// <param name="xmlSourcePath">The fully qualified path of the generated changeset XML document.</param>
        /// <param name="transformTargetPath">The fully qualified target path to use for the transformed XML document.</param>
        private void TransformChangesetDocument( string xmlSourcePath, string transformTargetPath )
        {
            string transformSourcePath = Path.GetTempFileName();

            try
            {
                // Download the latest version of the transform from the TFS server.
                DownloadEmailTransform( transformSourcePath );

                // Read the downloaded transform and perform the transform.
                _logger.Verbose( "Opening the downloaded XSLT transform" );
                using ( FileStream sourceStream = new FileStream( transformSourcePath, FileMode.Open, FileAccess.Read ) )
                {
                    XslCompiledTransform transform = new XslCompiledTransform();
                    XmlReaderSettings readerSettings = new XmlReaderSettings();

                    // We use entity definitions in the standard XSLT, so make sure they
                    // are allowed.
                    readerSettings.ProhibitDtd = false;

                    using ( XmlReader reader = XmlReader.Create( sourceStream, readerSettings ) )
                    {
                        XsltArgumentList parameters = new XsltArgumentList();

                        // Add the URL of the TFS server as a parameter for the stylesheet so we can
                        // generate the correct URLs to view the changeset and associated work items.
                        parameters.AddParam( "tfsUrl", string.Empty, Settings.TeamProjects.ServerUrl.ToString() );

                        _logger.Verbose( "Loading the XSLT transform" );
                        transform.Load( reader );

                        _logger.Verbose( "Running the XSLT transform: source = '{0}', target = '{1}'", xmlSourcePath, transformTargetPath );
                        using ( FileStream transformTarget = new FileStream( transformTargetPath, FileMode.Create, FileAccess.Write, FileShare.None ) )
                        {
                            transform.Transform( xmlSourcePath, parameters, transformTarget );
                        }

                        _logger.Verbose( "XSLT transform complete" );
                    }
                }
            }
            finally
            {
                // Delete the downloaded XSLT file.
                _logger.Verbose( "Deleting the downloaded XSLT transform - '{0}'", transformSourcePath );
                File.Delete( transformSourcePath );
            }
        }

        /// <summary>
        /// Downloads the XSLT transform that should be used to transform
        /// the changeset XML document to a HTML e-mail from the TFS server.
        /// </summary>
        /// <param name="transformSourcePath">The fully qualified local path the transform should be downloaded to.</param>
        private void DownloadEmailTransform( string transformSourcePath )
        {
            _logger.Verbose( "Downloading the XSLT transform item '{0}' to '{1}'", Settings.Transform.Source, transformSourcePath );

            try
            {
                Item transformItem = GetItem( Settings.Transform.Source );

                VersionControl.DownloadFile( transformItem, transformSourcePath );
            }
            catch ( VersionControlException vcex )
            {
                string exceptionMessage = string.Format( CultureInfo.CurrentCulture, Properties.Strings.CheckinEventProcessor_CouldNotDownloadXslt, Settings.Transform.Source );

                _logger.Error( "Couldn't download the XSLT transform from TFS: {0}", vcex.Message );

                throw new CheckinEventProcessorException( exceptionMessage, vcex );
            }
        }
        #endregion

        #region -- Changeset filtering
        /// <summary>
        /// Determines if the <see cref="Changeset"/> being processed affects one of the
        /// <see cref="TeamProject"/>s that have been configured for checkin e-mail notifications.
        /// </summary>
        /// <returns><c>true</c> if the changeset should be processed, <c>false</c> otherwise.</returns>
        private bool ShouldProcessChangeset()
        {
            _logger.Verbose( "CheckinEventProcessor.ShouldProcessChangeset" );

            // Check each project affected by the changeset to see if it's a
            // project that we're configured to send checkin notification emails for.
            foreach ( TeamProject project in AffectedProjects )
            {
                if ( IsTeamProjectConfigured( project ) )
                {
                    return !IsIgnoreUser();
                }
            }

            return false;
        }

        /// <summary>
        /// Checks if the specified TFS project is a project that we're configured
        /// to process.
        /// </summary>
        /// <param name="project">The TFS project to check.</param>
        /// <returns><c>true</c> if the project should be processed, <c>false</c> otherwise.</returns>
        private bool IsTeamProjectConfigured( TeamProject project )
        {
            _logger.Verbose( "CheckinEventProcessor.IsTeamProjectConfigured( \"{0}\" )", project.Name );

            foreach ( Configuration.TeamProject configuredProject in Settings.TeamProjects.Projects )
            {
                if ( StringComparer.InvariantCultureIgnoreCase.Compare( configuredProject.Name, project.Name ) == 0 )
                {
                    _logger.Verbose( "The TFS Project '{0}' is configured for check-in e-mail notifications ({1} - {2})", project.Name, configuredProject.Name, configuredProject.SubscribersItemPath );
                    return true;
                }
            }

            _logger.Verbose( "The TFS Project '{0}' is not configured for check-in e-mail notifications.", project.Name );
            return false;
        }

        /// <summary>
        /// Determines if the changeset we're processing was committed by a user
        /// that's defined as a user whose checkins should be ignored in the
        /// application configuration.
        /// </summary>
        /// <returns><c>true</c> if all changesets by this user should be ignored, <c>false</c> otherwise.</returns>
        private bool IsIgnoreUser()
        {
            if ( Settings.IgnoreUsers != null )
            {
                string changesetUser = Changeset.Committer;

                _logger.Verbose( "Checking if the user {0} is a user that should be ignored", changesetUser );

                foreach ( Configuration.IgnoreUser ignoreUser in Settings.IgnoreUsers.Users )
                {
                    if ( StringComparer.OrdinalIgnoreCase.Compare( ignoreUser.Name, changesetUser ) == 0 )
                    {
                        _logger.Information( "Skipping processing of changeset {0} since the changeset user ({1}) is defined as an ignore user", Changeset.ChangesetId, changesetUser );
                        return true;
                    }
                }

                _logger.Verbose( "The changeset user {0} isn't defined as an ignore user", changesetUser );
                return false;
            }
            else
            {
                _logger.Verbose( "No ignore users defined." );
                return false;
            }
        }

        /// <summary>
        /// Retrieves the list of unique TFS projects that are
        /// affected by the <see cref="Changeset"/> being processed.
        /// </summary>
        /// <returns>A list containing the TFS projects affected by the changeset.</returns>
        [SuppressMessage( "Microsoft.Globalization", "CA1309", Justification = "Unclear what the name restrictions are for TFS projects, hence we're staying away from OrdinalIgnoreCase" )]
        private ReadOnlyCollection<TeamProject> GetChangesetProjects()
        {
            Dictionary<string, TeamProject> uniqueProjects = new Dictionary<string, TeamProject>( StringComparer.InvariantCultureIgnoreCase );

            _logger.Verbose( "Looking up the TFS projects affected by the changeset" );

            // Get all of the unique TFS projects affected by the Changeset.
            foreach ( Change change in Changeset.Changes )
            {
                TeamProject itemProject = VersionControl.GetProject( change.Item );

                _logger.Verbose( "Item = '{0}', Project = '{1}", change.Item.ServerItem, itemProject.Name );

                if ( !uniqueProjects.ContainsKey( itemProject.Name ) )
                {
                    uniqueProjects[ itemProject.Name ] = itemProject;
                }
            }

            _logger.Verbose( "Found {0} TFS projects affected by the changeset", uniqueProjects.Count );
            return new ReadOnlyCollection<TeamProject>( new List<TeamProject>( uniqueProjects.Values ) );
        }
        #endregion

        #region -- Protected TFS-related properties and methods
        /// <summary>
        /// The version control service for the TFS server
        /// we're configured to use.
        /// </summary>
        protected VersionControlService VersionControl
        {
            get
            {
                if ( _versionControl == null )
                {
                    _logger.Information( "Creating the VersionControlService object for '{0}'", Settings.TeamProjects.ServerUrl.ToString() );
                    _versionControl = new VersionControlService( Settings.TeamProjects.ServerUrl );
                }

                return _versionControl;
            }
        } private VersionControlService _versionControl;

        /// <summary>
        /// Retrieves the current version of the <see cref="Item"/> with the 
        /// specified path from the TFS server.
        /// </summary>
        /// <param name="itemPath">The fully qualified path of the item to retrieve.</param>
        /// <returns>The requested item.</returns>
        protected Item GetItem( string itemPath )
        {
            _logger.Verbose( "Retrieving item '{0}'", itemPath );
            return VersionControl.VersionControl.GetItem( itemPath );
        }
        #endregion

        #region -- Protected settings-related properties
        /// <summary>
        /// The settings defined for TFS Spam in the application
        /// configuration file.
        /// </summary>
        protected Configuration.TfsSpamConfig Settings
        {
            get 
            {
                if ( _configuration == null )
                {
                    System.Configuration.Configuration applicationConfiguration = ConfigurationManager.OpenExeConfiguration( ConfigurationUserLevel.None );
                    
                    _configuration = applicationConfiguration.GetSectionGroup( "tfsSpam" ) as Configuration.TfsSpamConfig;
                }

                return _configuration;
            }
        } private Configuration.TfsSpamConfig _configuration;
        #endregion
    }
}
